/**
 * dirPagination - AngularJS module for paginating (almost) anything.
 *
 *
 * Credits
 * =======
 *
 * Daniel Tabuenca: https://groups.google.com/d/msg/angular/an9QpzqIYiM/r8v-3W1X5vcJ
 * for the idea on how to dynamically invoke the ng-repeat directive.
 *
 * I borrowed a couple of lines and a few attribute names from the AngularUI Bootstrap project:
 * https://github.com/angular-ui/bootstrap/blob/master/src/pagination/pagination.js
 *
 * Copyright 2014 Michael Bromley <michael@michaelbromley.co.uk>
 *
 * @antonios 18-Aug-16 enhanced this with a material-design pagination template
 */

(function () {

  /**
   * Config
   */
  var moduleName = 'angularUtils.directives.dirPagination';
  var DEFAULT_ID = '__default';

  /**
   * Module
   */
  angular.module(moduleName, [])
    .directive('dirPaginate', ['$compile', '$parse', 'paginationService', dirPaginateDirective])
    .directive('dirPaginateNoCompile', noCompileDirective)
    .directive('dirPaginationControls', ['paginationService', 'paginationTemplate', dirPaginationControlsDirective])
    .filter('itemsPerPage', ['paginationService', itemsPerPageFilter])
    .service('paginationService', paginationService)
    .provider('paginationTemplate', paginationTemplateProvider)
    .run(['$templateCache', dirPaginationControlsTemplateInstaller]);

  function dirPaginateDirective($compile, $parse, paginationService) {

    return {
      terminal: true,
      multiElement: true,
      priority: 100,
      compile: dirPaginationCompileFn
    };

    function dirPaginationCompileFn(tElement, tAttrs) {

      var expression = tAttrs.dirPaginate;
      // regex taken directly from https://github.com/angular/angular.js/blob/v1.4.x/src/ng/directive/ngRepeat.js#L339
      var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/);

      var filterPattern = /\|\s*itemsPerPage\s*:\s*(.*\(\s*\w*\)|([^\)]*?(?=\s+as\s+))|[^\)]*)/;
      if (match[2].match(filterPattern) === null) {
        throw 'pagination directive: the \'itemsPerPage\' filter must be set.';
      }
      var itemsPerPageFilterRemoved = match[2].replace(filterPattern, '');
      var collectionGetter = $parse(itemsPerPageFilterRemoved);

      addNoCompileAttributes(tElement);

      // If any value is specified for paginationId, we register the un-evaluated expression at this stage for the benefit of any
      // dir-pagination-controls directives that may be looking for this ID.
      var rawId = tAttrs.paginationId || DEFAULT_ID;
      paginationService.registerInstance(rawId);

      return function dirPaginationLinkFn(scope, element, attrs) {

        // Now that we have access to the `scope` we can interpolate any expression given in the paginationId attribute and
        // potentially register a new ID if it evaluates to a different value than the rawId.
        var paginationId = $parse(attrs.paginationId)(scope) || attrs.paginationId || DEFAULT_ID;

        // (TODO: this seems sound, but I'm reverting as many bug reports followed it's introduction in 0.11.0.
        // Needs more investigation.)
        // In case rawId != paginationId we deregister using rawId for the sake of general cleanliness
        // before registering using paginationId
        // paginationService.deregisterInstance(rawId);
        paginationService.registerInstance(paginationId);

        var repeatExpression = getRepeatExpression(expression, paginationId);
        addNgRepeatToElement(element, attrs, repeatExpression);

        removeTemporaryAttributes(element);
        var compiled = $compile(element);

        var currentPageGetter = makeCurrentPageGetterFn(scope, attrs, paginationId);
        paginationService.setCurrentPageParser(paginationId, currentPageGetter, scope);

        if (typeof attrs.totalItems !== 'undefined') {
          paginationService.setAsyncModeTrue(paginationId);
          scope.$watch(function () {
            return $parse(attrs.totalItems)(scope);
          }, function (result) {
            if (0 <= result) {
              paginationService.setCollectionLength(paginationId, result);
            }
          });
        } else {
          paginationService.setAsyncModeFalse(paginationId);
          scope.$watchCollection(function () {
            return collectionGetter(scope);
          }, function (collection) {
            if (collection) {
              var collectionLength = (collection instanceof Array) ? collection.length : Object.keys(collection).length;
              paginationService.setCollectionLength(paginationId, collectionLength);
            }
          });
        }

        // Delegate to the link function returned by the new compilation of the ng-repeat
        compiled(scope);

        // (TODO: Reverting this due to many bug reports in v 0.11.0. Needs investigation as the
        // principle is sound)
        // When the scope is destroyed, we make sure to remove the reference to it in paginationService
        // so that it can be properly garbage collected
        // scope.$on('$destroy', function destroyDirPagination() {
        //     paginationService.deregisterInstance(paginationId);
        // });
      };
    }

    /**
     * If a pagination id has been specified, we need to check that it is present as the second argument passed to
     * the itemsPerPage filter. If it is not there, we add it and return the modified expression.
     *
     * @param expression
     * @param paginationId
     * @returns {*}
     */
    function getRepeatExpression(expression, paginationId) {
      var repeatExpression,
        idDefinedInFilter = !!expression.match(/(\|\s*itemsPerPage\s*:[^|]*:[^|]*)/);

      if (paginationId !== DEFAULT_ID && !idDefinedInFilter) {
        repeatExpression = expression.replace(/(\|\s*itemsPerPage\s*:\s*[^|\s]*)/, "$1 : '" + paginationId + "'");
      } else {
        repeatExpression = expression;
      }

      return repeatExpression;
    }

    /**
     * Adds the ng-repeat directive to the element. In the case of multi-element (-start, -end) it adds the
     * appropriate multi-element ng-repeat to the first and last element in the range.
     * @param element
     * @param attrs
     * @param repeatExpression
     */
    function addNgRepeatToElement(element, attrs, repeatExpression) {
      if (element[0].hasAttribute('dir-paginate-start') || element[0].hasAttribute('data-dir-paginate-start')) {
        // using multiElement mode (dir-paginate-start, dir-paginate-end)
        attrs.$set('ngRepeatStart', repeatExpression);
        element.eq(element.length - 1).attr('ng-repeat-end', true);
      } else {
        attrs.$set('ngRepeat', repeatExpression);
      }
    }

    /**
     * Adds the dir-paginate-no-compile directive to each element in the tElement range.
     * @param tElement
     */
    function addNoCompileAttributes(tElement) {
      angular.forEach(tElement, function (el) {
        if (el.nodeType === 1) {
          angular.element(el).attr('dir-paginate-no-compile', true);
        }
      });
    }

    /**
     * Removes the variations on dir-paginate (data-, -start, -end) and the dir-paginate-no-compile directives.
     * @param element
     */
    function removeTemporaryAttributes(element) {
      angular.forEach(element, function (el) {
        if (el.nodeType === 1) {
          angular.element(el).removeAttr('dir-paginate-no-compile');
        }
      });
      element.eq(0).removeAttr('dir-paginate-start').removeAttr('dir-paginate').removeAttr('data-dir-paginate-start').removeAttr('data-dir-paginate');
      element.eq(element.length - 1).removeAttr('dir-paginate-end').removeAttr('data-dir-paginate-end');
    }

    /**
     * Creates a getter function for the current-page attribute, using the expression provided or a default value if
     * no current-page expression was specified.
     *
     * @param scope
     * @param attrs
     * @param paginationId
     * @returns {*}
     */
    function makeCurrentPageGetterFn(scope, attrs, paginationId) {
      var currentPageGetter;
      if (attrs.currentPage) {
        currentPageGetter = $parse(attrs.currentPage);
      } else {
        // If the current-page attribute was not set, we'll make our own.
        // Replace any non-alphanumeric characters which might confuse
        // the $parse service and give unexpected results.
        // See https://github.com/michaelbromley/angularUtils/issues/233
        var defaultCurrentPage = (paginationId + '__currentPage').replace(/\W/g, '_');
        scope[defaultCurrentPage] = 1;
        currentPageGetter = $parse(defaultCurrentPage);
      }
      return currentPageGetter;
    }
  }

  /**
   * This is a helper directive that allows correct compilation when in multi-element mode (ie dir-paginate-start, dir-paginate-end).
   * It is dynamically added to all elements in the dir-paginate compile function, and it prevents further compilation of
   * any inner directives. It is then removed in the link function, and all inner directives are then manually compiled.
   */
  function noCompileDirective() {
    return {
      priority: 5000,
      terminal: true
    };
  }

  function dirPaginationControlsTemplateInstaller($templateCache) {
    var strVar = "";
    strVar += "<section style=\"margin: 5px 10px 0;\" layout=\"row\" layout-align=\"center\" ng-if=\"1 < pages.length || !autoHide\" class=\"pagination\"> <md-button aria-label=\"Previous page\" ng-if=\"boundaryLinks\" ng-disabled=\"pagination.current===1\" ng-click=\"setCurrent(1)\"> <ng-md-icon icon=\"first_page\"><\/ng-md-icon> <\/md-button> <md-button aria-label=\"First page\" ng-if=\"directionLinks\" ng-disabled=\"pagination.current===1\" ng-click=\"setCurrent(pagination.current - 1)\"> <ng-md-icon class=\"fa fa-chevron-left\"><\/ng-md-icon> <\/md-button> <md-button ng-repeat=\"pageNumber in pages track by tracker(pageNumber, $index)\" ng-class=\"{'md-primary' : pagination.current==pageNumber}\" ng-disabled=\"pageNumber==='...'\" ng-click=\"setCurrent(pageNumber)\">{{pageNumber}}<\/md-button> <md-button aria-label=\"Last page\" ng-if=\"directionLinks\" ng-disabled=\"pagination.current===pagination.last\" ng-click=\"setCurrent(pagination.current + 1)\"> <ng-md-icon class=\"fa fa-chevron-right\"><\/ng-md-icon> <\/md-button> <md-button ng-if=\"boundaryLinks\" ng-disabled=\"pagination.current===pagination.last\" ng-click=\"setCurrent(pagination.last)\"> <ng-md-icon icon=\"last_page\"><\/ng-md-icon> <\/md-button><\/section>";

    $templateCache.put('angularUtils.directives.dirPagination.template', strVar);
  }

  function dirPaginationControlsDirective(paginationService, paginationTemplate) {

    var numberRegex = /^\d+$/;

    var DDO = {
      restrict: 'AE',
      scope: {
        maxSize: '=?',
        onPageChange: '&?',
        paginationId: '=?',
        autoHide: '=?'
      },
      link: dirPaginationControlsLinkFn
    };

    // We need to check the paginationTemplate service to see whether a template path or
    // string has been specified, and add the `template` or `templateUrl` property to
    // the DDO as appropriate. The order of priority to decide which template to use is
    // (highest priority first):
    // 1. paginationTemplate.getString()
    // 2. attrs.templateUrl
    // 3. paginationTemplate.getPath()
    var templateString = paginationTemplate.getString();
    if (templateString !== undefined) {
      DDO.template = templateString;
    } else {
      DDO.templateUrl = function (elem, attrs) {
        return attrs.templateUrl || paginationTemplate.getPath();
      };
    }
    return DDO;

    function dirPaginationControlsLinkFn(scope, element, attrs) {

      // rawId is the un-interpolated value of the pagination-id attribute. This is only important when the corresponding dir-paginate directive has
      // not yet been linked (e.g. if it is inside an ng-if block), and in that case it prevents this controls directive from assuming that there is
      // no corresponding dir-paginate directive and wrongly throwing an exception.
      var rawId = attrs.paginationId || DEFAULT_ID;
      var paginationId = scope.paginationId || attrs.paginationId || DEFAULT_ID;

      if (!paginationService.isRegistered(paginationId) && !paginationService.isRegistered(rawId)) {
        var idMessage = (paginationId !== DEFAULT_ID) ? ' (id: ' + paginationId + ') ' : ' ';
        if (window.console) {
          console.warn('Pagination directive: the pagination controls' + idMessage + 'cannot be used without the corresponding pagination directive, which was not found at link time.');
        }
      }

      if (!scope.maxSize) {
        scope.maxSize = 9;
      }
      scope.autoHide = scope.autoHide === undefined ? true : scope.autoHide;
      scope.directionLinks = angular.isDefined(attrs.directionLinks) ? scope.$parent.$eval(attrs.directionLinks) : true;
      scope.boundaryLinks = angular.isDefined(attrs.boundaryLinks) ? scope.$parent.$eval(attrs.boundaryLinks) : false;

      var paginationRange = Math.max(scope.maxSize, 5);
      scope.pages = [];
      scope.pagination = {
        last: 1,
        current: 1
      };
      scope.range = {
        lower: 1,
        upper: 1,
        total: 1
      };

      scope.$watch('maxSize', function (val) {
        if (val) {
          paginationRange = Math.max(scope.maxSize, 5);
          generatePagination();
        }
      });

      scope.$watch(function () {
        if (paginationService.isRegistered(paginationId)) {
          return (paginationService.getCollectionLength(paginationId) + 1) * paginationService.getItemsPerPage(paginationId);
        }
      }, function (length) {
        if (0 < length) {
          generatePagination();
        }
      });

      scope.$watch(function () {
        if (paginationService.isRegistered(paginationId)) {
          return (paginationService.getItemsPerPage(paginationId));
        }
      }, function (current, previous) {
        if (current != previous && typeof previous !== 'undefined') {
          goToPage(scope.pagination.current);
        }
      });

      scope.$watch(function () {
        if (paginationService.isRegistered(paginationId)) {
          return paginationService.getCurrentPage(paginationId);
        }
      }, function (currentPage, previousPage) {
        if (currentPage != previousPage) {
          goToPage(currentPage);
        }
      });

      scope.setCurrent = function (num) {
        if (paginationService.isRegistered(paginationId) && isValidPageNumber(num)) {
          num = parseInt(num, 10);
          paginationService.setCurrentPage(paginationId, num);
        }
      };

      /**
       * Custom "track by" function which allows for duplicate "..." entries on long lists,
       * yet fixes the problem of wrongly-highlighted links which happens when using
       * "track by $index" - see https://github.com/michaelbromley/angularUtils/issues/153
       * @param id
       * @param index
       * @returns {string}
       */
      scope.tracker = function (id, index) {
        return id + '_' + index;
      };

      function goToPage(num) {
        if (paginationService.isRegistered(paginationId) && isValidPageNumber(num)) {
          var oldPageNumber = scope.pagination.current;

          scope.pages = generatePagesArray(num, paginationService.getCollectionLength(paginationId), paginationService.getItemsPerPage(paginationId), paginationRange);
          scope.pagination.current = num;
          updateRangeValues();

          // if a callback has been set, then call it with the page number as the first argument
          // and the previous page number as a second argument
          if (scope.onPageChange) {
            scope.onPageChange({
              newPageNumber: num,
              oldPageNumber: oldPageNumber
            });
          }
        }
      }

      function generatePagination() {
        if (paginationService.isRegistered(paginationId)) {
          var page = parseInt(paginationService.getCurrentPage(paginationId)) || 1;
          scope.pages = generatePagesArray(page, paginationService.getCollectionLength(paginationId), paginationService.getItemsPerPage(paginationId), paginationRange);
          scope.pagination.current = page;
          scope.pagination.last = scope.pages[scope.pages.length - 1];
          if (scope.pagination.last < scope.pagination.current) {
            scope.setCurrent(scope.pagination.last);
          } else {
            updateRangeValues();
          }
        }
      }

      /**
       * This function updates the values (lower, upper, total) of the `scope.range` object, which can be used in the pagination
       * template to display the current page range, e.g. "showing 21 - 40 of 144 results";
       */
      function updateRangeValues() {
        if (paginationService.isRegistered(paginationId)) {
          var currentPage = paginationService.getCurrentPage(paginationId),
            itemsPerPage = paginationService.getItemsPerPage(paginationId),
            totalItems = paginationService.getCollectionLength(paginationId);

          scope.range.lower = (currentPage - 1) * itemsPerPage + 1;
          scope.range.upper = Math.min(currentPage * itemsPerPage, totalItems);
          scope.range.total = totalItems;
        }
      }

      function isValidPageNumber(num) {
        return (numberRegex.test(num) && (0 < num && num <= scope.pagination.last));
      }
    }

    /**
     * Generate an array of page numbers (or the '...' string) which is used in an ng-repeat to generate the
     * links used in pagination
     *
     * @param currentPage
     * @param rowsPerPage
     * @param paginationRange
     * @param collectionLength
     * @returns {Array}
     */
    function generatePagesArray(currentPage, collectionLength, rowsPerPage, paginationRange) {
      var pages = [];
      var totalPages = Math.ceil(collectionLength / rowsPerPage);
      var halfWay = Math.ceil(paginationRange / 2);
      var position;

      if (currentPage <= halfWay) {
        position = 'start';
      } else if (totalPages - halfWay < currentPage) {
        position = 'end';
      } else {
        position = 'middle';
      }

      var ellipsesNeeded = paginationRange < totalPages;
      var i = 1;
      while (i <= totalPages && i <= paginationRange) {
        var pageNumber = calculatePageNumber(i, currentPage, paginationRange, totalPages);

        var openingEllipsesNeeded = (i === 2 && (position === 'middle' || position === 'end'));
        var closingEllipsesNeeded = (i === paginationRange - 1 && (position === 'middle' || position === 'start'));
        if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
          pages.push('...');
        } else {
          pages.push(pageNumber);
        }
        i++;
      }
      return pages;
    }

    /**
     * Given the position in the sequence of pagination links [i], figure out what page number corresponds to that position.
     *
     * @param i
     * @param currentPage
     * @param paginationRange
     * @param totalPages
     * @returns {*}
     */
    function calculatePageNumber(i, currentPage, paginationRange, totalPages) {
      var halfWay = Math.ceil(paginationRange / 2);
      if (i === paginationRange) {
        return totalPages;
      } else if (i === 1) {
        return i;
      } else if (paginationRange < totalPages) {
        if (totalPages - halfWay < currentPage) {
          return totalPages - paginationRange + i;
        } else if (halfWay < currentPage) {
          return currentPage - halfWay + i;
        } else {
          return i;
        }
      } else {
        return i;
      }
    }
  }

  /**
   * This filter slices the collection into pages based on the current page number and number of items per page.
   * @param paginationService
   * @returns {Function}
   */
  function itemsPerPageFilter(paginationService) {

    return function (collection, itemsPerPage, paginationId) {
      if (typeof (paginationId) === 'undefined') {
        paginationId = DEFAULT_ID;
      }
      if (!paginationService.isRegistered(paginationId)) {
        throw 'pagination directive: the itemsPerPage id argument (id: ' + paginationId + ') does not match a registered pagination-id.';
      }
      var end;
      var start;
      if (angular.isObject(collection)) {
        itemsPerPage = parseInt(itemsPerPage) || 9999999999;
        if (paginationService.isAsyncMode(paginationId)) {
          start = 0;
        } else {
          start = (paginationService.getCurrentPage(paginationId) - 1) * itemsPerPage;
        }
        end = start + itemsPerPage;
        paginationService.setItemsPerPage(paginationId, itemsPerPage);

        if (collection instanceof Array) {
          // the array just needs to be sliced
          return collection.slice(start, end);
        } else {
          // in the case of an object, we need to get an array of keys, slice that, then map back to
          // the original object.
          var slicedObject = {};
          angular.forEach(keys(collection).slice(start, end), function (key) {
            slicedObject[key] = collection[key];
          });
          return slicedObject;
        }
      } else {
        return collection;
      }
    };
  }

  /**
   * Shim for the Object.keys() method which does not exist in IE < 9
   * @param obj
   * @returns {Array}
   */
  function keys(obj) {
    if (!Object.keys) {
      var objKeys = [];
      for (var i in obj) {
        if (obj.hasOwnProperty(i)) {
          objKeys.push(i);
        }
      }
      return objKeys;
    } else {
      return Object.keys(obj);
    }
  }

  /**
   * This service allows the various parts of the module to communicate and stay in sync.
   */
  function paginationService() {

    var instances = {};
    var lastRegisteredInstance;

    this.registerInstance = function (instanceId) {
      if (typeof instances[instanceId] === 'undefined') {
        instances[instanceId] = {
          asyncMode: false
        };
        lastRegisteredInstance = instanceId;
      }
    };

    this.deregisterInstance = function (instanceId) {
      delete instances[instanceId];
    };

    this.isRegistered = function (instanceId) {
      return (typeof instances[instanceId] !== 'undefined');
    };

    this.getLastInstanceId = function () {
      return lastRegisteredInstance;
    };

    this.setCurrentPageParser = function (instanceId, val, scope) {
      instances[instanceId].currentPageParser = val;
      instances[instanceId].context = scope;
    };
    this.setCurrentPage = function (instanceId, val) {
      instances[instanceId].currentPageParser.assign(instances[instanceId].context, val);
    };
    this.getCurrentPage = function (instanceId) {
      var parser = instances[instanceId].currentPageParser;
      return parser ? parser(instances[instanceId].context) : 1;
    };

    this.setItemsPerPage = function (instanceId, val) {
      instances[instanceId].itemsPerPage = val;
    };
    this.getItemsPerPage = function (instanceId) {
      return instances[instanceId].itemsPerPage;
    };

    this.setCollectionLength = function (instanceId, val) {
      instances[instanceId].collectionLength = val;
    };
    this.getCollectionLength = function (instanceId) {
      return instances[instanceId].collectionLength;
    };

    this.setAsyncModeTrue = function (instanceId) {
      instances[instanceId].asyncMode = true;
    };

    this.setAsyncModeFalse = function (instanceId) {
      instances[instanceId].asyncMode = false;
    };

    this.isAsyncMode = function (instanceId) {
      return instances[instanceId].asyncMode;
    };
  }

  /**
   * This provider allows global configuration of the template path used by the dir-pagination-controls directive.
   */
  function paginationTemplateProvider() {

    var templatePath = 'angularUtils.directives.dirPagination.template';
    var templateString;

    /**
     * Set a templateUrl to be used by all instances of <dir-pagination-controls>
     * @param {String} path
     */
    this.setPath = function (path) {
      templatePath = path;
    };

    /**
     * Set a string of HTML to be used as a template by all instances
     * of <dir-pagination-controls>. If both a path *and* a string have been set,
     * the string takes precedence.
     * @param {String} str
     */
    this.setString = function (str) {
      templateString = str;
    };

    this.$get = function () {
      return {
        getPath: function () {
          return templatePath;
        },
        getString: function () {
          return templateString;
        }
      };
    };
  }
})();
